(function(angular){
	/**
	 * format string (replace {0-9} by arguments)
	 * eg. '1{0}3{1}'.format('2', '4') -> 1234
	 **/
	if(!String.prototype.format){
		String.prototype.format = function() {
			var args = Array.prototype.slice.call(arguments);
			return this.replace(/\{(\d+)\}/g, 
				function(m, i){
					return args[i];
				});
		};
	}
	/**
	 * get length including CJK
	 **/
	if(!String.prototype.charlen){
		String.prototype.charlen = function() {
			var arr = this.match(/[^\x00-\xff]/ig);
			return this.length + (arr === null ? 0 : arr.length);
		};
	}

	if(!String.prototype.contains){
		String.prototype.contains = function(sub) {
			return this.indexOf(sub) !== -1;
		};
	}

	if(!Array.prototype.contains){
		Array.prototype.contains = function(el) {
			var i;
			for(i = 0; i < this.length; i++) { 
				if(this[i] === el){
					return true;  
				}
			}  
			return false;  
		};
	}

	var md = angular.module('extValid', []);
	md.value('extValidConf', {
		invalidClass: 'ng-invalid', 
		validClass: 'ng-valid', 

		sp: '__'
	});

	md.service('extValidTips', function(extValidConf){
		// to be overwrite
		this.on = function(el, rule, msg){
			el.title = msg;

			var ngEl = angular.element(el);
			if(ngEl.hasClass(extValidConf.validClass)){
				ngEl.removeClass(extValidConf.validClass).addClass(extValidConf.invalidClass);
			}
		};
		this.off = function(el){
			el.title = '';

			var ngEl = angular.element(el);
			if(ngEl.hasClass(extValidConf.invalidClass)){
				ngEl.removeClass(extValidConf.invalidClass).addClass(extValidConf.validClass);
			}
		};
	});

	md.service('extValidChecker', function($parse, extValidConf, extValidTips){
		// call this method before submit your form or do a ajax request
		// because angular directive donot trigger auto
		// so you should check those has rule required
		this.checkFormWithVal = function($form, returnRequiredModel, $index){
			$index = $index || 0;

			var formName = $form.$name;
			var context = document.querySelectorAll('form[name="{0}"],[ng-form="{0}"],[data-ng-form="{0}"]'.format(formName));

			// ng-repeat create forms with same name, use one
			context = context[$index];

			var elLl = context.querySelectorAll('[ext-valid]');
			var len = elLl.length;
			if(!len)
				return true;

			// angular unshift value="?" option
			var isSelectNull = function(one, val){
				return '?' === val && one.nodeName === 'SELECT';
			};

			var that = this;
			var flags = [];
			// no break -> show all tips of form inputs that require value
			var i = 0;
			for(; i < len; i++){
				var el = elLl[i];
				var val = el.value;
				if(val && !isSelectNull(el, val))
					continue;

				var ngEl = angular.element(el);
				var rules = ngEl.attr('ext-valid');
				if(!rules)
					continue;

				var arr = rules.split(' ');
				// 'r' means required
				if(arr.contains('r')){
					var modelName = ngEl.attr('ng-model') || ngEl.attr('data-ng-model');
					flags.push(modelName);
					extValidTips.on(el, 'r', ngEl.attr('ext-valid-tips') || that.getMsg('r'));
				}
			}
			return returnRequiredModel ? flags : !flags.length;
		};

		// check a form, skip some models and rules
		this.validForm = function($form, skipedList, ruleList, index){
			// angular NgFormController $error -> {rule: false / NgModelController instance}
			// transfer to {modelName: [rule1, rule2]}
			var modelRuleItems = {};

			// $yourForm.$dirty === false => must check required use dom
			var requiredFlags = this.checkFormWithVal($form, true, index);
			var requiredFlagsTarget = requiredFlags;

			// skip models if skip rules include required
			if(ruleList.contains('r')){
				requiredFlagsTarget = _.difference(requiredFlags, skipedList);

				_.each(_.intersection(skipedList, requiredFlags), function(modelName){
					if(!modelRuleItems[modelName])
						modelRuleItems[modelName] = [];

					modelRuleItems[modelName].push('r');
				});
			}

			// still has model, required check failed
			var isRequiredFlag = !requiredFlagsTarget.length;

			// check if NgFormController $error has other rule
			var isValidForm = $form.$valid;
			if(!isValidForm){
				var errorToken = [];

				var errors = $form.$error;
				for(var key in errors){
					// __ is a special split string work with extValid directive
					// key === modelName__rule
					if(!errors.hasOwnProperty(key) || !key.contains(extValidConf.sp)){
						continue;
					}

					var arr = key.split(extValidConf.sp);
					var modelName = arr[0];
					var rule = arr[1];

					// === false => check ok
					if(errors[key] === false){
						continue;
					}

					if(!modelRuleItems[modelName]){
						modelRuleItems[modelName] = [];
					}
					modelRuleItems[modelName].push(rule);

					var needSkip = ruleList.contains(rule) && skipedList.contains(modelName);
					if(needSkip){
						continue;
					}

					// check failed, because the $error key is from model that not in skip models
					errorToken.push(key);
				}

				isValidForm = errorToken.length === 0;
			}

			this.filterTipsOff(modelRuleItems, skipedList, ruleList, index);

			return isRequiredFlag && isValidForm;
		};

			// tips off
		this.filterTipsOff = function(modelRuleItems, skipedList, ruleList, index){
			for(var modelName in modelRuleItems){
				if(!skipedList.contains(modelName)){
					continue;
				}

				var rules = modelRuleItems[modelName];
				var rulesSliced = _.difference(rules, ruleList);

				if(!rulesSliced.length){
					var targetEl = document.querySelectorAll('[ng-model="{0}"],[data-ng-model="{0}"]'.format(modelName));
					var targetElNg = angular.element(targetEl);
					if(index != null)
						targetElNg = targetElNg.eq(index);

					extValidTips.off(targetElNg);
				}
			}
		};

		// check if val fit these valid rules
		this.check = function(val, rules, $scope, defaultTips, extendParam){
			// no rules
			if(!rules)
				return {flag: true};

			var arr = rules.split(' ');
			// 'r' means required
			// multiple select blank array == '' -> true, use string to compare
			var isBlank = val === null || val === undefined || val == '';
			if(!arr.contains('r') && isBlank)
				return {flag: true};

			var i = 0;
			for(; i < arr.length; i++){
				var rule = arr[i];
				if(!rule)
					continue;

				var flag = true;
				if('r' === rule){
					flag = !isBlank;
				}else if(rule.contains(':')){
					// rules that is complex
					flag = this.checkRule(val, rule.split(':'), $scope, extendParam);
				}else{
					var pat = this.pats[rule];
					if(pat instanceof RegExp){
						if(angular.isString(val)){
							flag = this.mat(val, pat);
						}
					}else if(angular.isFunction(pat)){
						flag = pat(val);
					}else{
						// only support regexp and function
						flag = false;
					}
				}

				// if get string means valid failed, just show
				if(angular.isString(flag)){
					return {flag: false, msg: flag, rule: rule};
				}

				if(flag === false){
					var msg = this.getMsg(rule, defaultTips) || this.getMsg('tips.valid');
					return {flag: false, msg: msg, rule: rule};
				}
			}

			return {flag: true};
		};
		
		// eg. "fn:checkTarget" -> customized valid function
		// eg. "num:range:targetModel:+100" -> return true when val - model val(target_id) < 100
		// eg. "minlen:char:3"
		// eg. "maxval:float:3.23"
		this.checkRule = function(val, ruleArr, $scope, extendParam){
			var len = ruleArr.length;
			var pre = ruleArr[0];

			// customized valid function defined in controller $scope
			var getter, targetVal, rangeVal;
			if('fn' === pre){
				var fnName = ruleArr[1];
				getter = $parse(fnName);
				var fn = getter($scope);
				if(!fn){
					return true;
				}

				// this -> current scope
				return fn.call($scope, val, extendParam);
			}else if('num' === pre){
				if(len != 4){
					console.log('Invalid rules : ' + ruleArr);
					return false;
				}

				// val targetVal is string, usually generated by user's input
				getter = $parse(ruleArr[2]);
				targetVal = getter($scope);
				if(!targetVal)
					return false;

				var currentVal = parseFloat(val);
				var targetNumVal = parseFloat(targetVal);
			
				rangeVal = parseInt(ruleArr[3], 10);
				if(ruleArr[1] === 'range' && currentVal > targetNumVal + rangeVal)
					return false;
				if(ruleArr[1] === 'rangeout' && currentVal < targetNumVal + rangeVal)
					return false;

				return true;
			}else if('minlen' === pre || 'maxlen' === pre){
				if(len != 3){
					console.log('Invalid rules : ' + ruleArr);
					return false;
				}

				var lenVal = parseInt(ruleArr[2], 10);
				if(ruleArr[0] === 'minlen' &&
					(('byte' === ruleArr[1] && val.length < lenVal) ||
					('char' === ruleArr[1] && val.charlen() < lenVal)))
					return false;
				if(ruleArr[0] === 'maxlen' &&
					(('byte' === ruleArr[1] && val.length > lenVal) ||
					('char' === ruleArr[1] && val.charlen() > lenVal)))
					return false;
				return true;
			}else if('minval' === pre || 'maxval' === pre){
				if(len != 3){
					console.log('Invalid rules : ' + ruleArr);
					return false;
				}

				targetVal = 'float' === ruleArr[1] ? parseFloat(ruleArr[2]) : parseInt(ruleArr[2], 10);
				var currentVal = 'float' === ruleArr[1] ? parseFloat(val) : parseInt(val, 10);
				if(pre === 'minval' && currentVal < targetVal)
					return false;
				if(pre === 'maxval' && currentVal > targetVal)
					return false;
				return true;
			}else{
				return true;
			}
		};

		this.mat = function(val, pat){
			if(!pat)
				return true;

			return pat.test(val);
		};

		this.getMsg = function(rule, tips){
			// if develeper giving tips (ext-valid-tips) when using this directive, return giving tips
			// if ext-valid-tips="label:Your model label", prepend 'Your model label' to tips and return
			tips = tips || '';
			if(tips && !tips.contains(':')){
				return tips;
			}

			var msg = this.msgs[rule];
			if(rule.contains(':')){
				var ruleFirst = rule.split(':')[0];
				if(['maxval', 'minval', 'maxlen', 'minlen'].contains(ruleFirst)){
					msg = this.msgs[ruleFirst];
				}
			}

			if(msg){
				var params0 = tips.contains(':') ? tips.split(':')[1] : '';
				var params1 = '';
				if(rule.indexOf('min') === 0 || rule.indexOf('max') === 0){
					var ruleArr = rule.split(':');
					// eg. rule = "maxval:float:3.23" -> show tips with 3.23
					params1 = ruleArr[ruleArr.length - 1];
				}

				return msg.format(params0, params1);
			}else{
				console.log('No tips for : ' + rule);
				return tips;
			}
		};

		// add your valid function using this
		/*
		eg.
		var myModule = angular.module('myModule', ['extValid']);
		myModule.run(['extValidChecker', function(extValidChecker){
			extValidChecker.regPat('rule.test', /^\d{2,3}$/, '数字两三个');
		}]);
		*/
		this.regPat = function(code, pat, msg){
			if(this.pats[code])
				return;

			this.pats[code] = pat;
			this.msgs[code] = msg;
		};

		// default rule / tips defination
		this.msgs = {
			'r': '{0} is required!',
			'date': '{0} should be as "yyyy-MM-dd"!',
			'int': '{0} must be an integer!',

			'minlen': '{0} charactor length must be greater than {1}!',
			'maxlen': '{0} charactor length must be less than {1}!',
			'maxval': '{0} value should be small than {1}!',
			'minval': '{0} value should be bigger than {1}!',

			'tips.valid': 'valid failed!',
		};

		// default rule -> regex/function defination
		this.pats = {
			'date': function(str){
				// yyyy-MM-dd as an example
				var arr = str.split('-');
				if(arr.length != 3)
					return false;

				if(arr[0].length > 4 || arr[1].length > 2 || arr[2].length > 2)
					return false;

				var year = parseInt(arr[0], 10);
				var month = parseInt(arr[1], 10);
				var day = parseInt(arr[2], 10);

				if(isNaN(year) || isNaN(month) || isNaN(day))
					return false;

				var maxYear = 9999;
				var minYear = 0;
				if(year < minYear || year > maxYear)
					return false;

				if(month < 1 || month > 12)
					return false;

				var dayInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

				var maxDayInMonth = dayInMonth[month - 1];

				var isLeapYear = 0== +year%4 && ((+year%100!=0) || (+year%400==0));
				if(2 === month && isLeapYear)
					maxDayInMonth = 29;

				if(day < 1 || day > maxDayInMonth)
					return false;

				return true;
			},
			'int': /^[\-\+]?([0-9]+)$/
		};
	});

	// validation -> donot watch $validity/$required (binding ng-show etc.), use tips instead
	// *** *** *** *** *** *** *** *** *** ***
	// *** *** *** *** *** *** *** *** *** ***
	md.directive('extValid', function($parse, extValidConf, extValidTips, extValidChecker){
		var extValidRefered = {};

		return {
			restrict: 'A',
			require: 'ngModel',
			link: function(scope, el, attrs, ctrl){
				var getRules = function(){
					return attrs.extValid;
				};

				var lastOldRules;
				var validFn = function(value, oldRules){
					var rules = getRules();
					var r = extValidChecker.check(value, rules, scope, attrs.extValidTips);

					if(lastOldRules && !oldRules)
						oldRules = lastOldRules;

					if(r.flag && oldRules){
						rules = rules ? rules + ' ' + oldRules : oldRules;
					}

					if(rules){
						// set form $error
						var arrInner = rules.split(' ');
						var i = 0;
						for(; i < arrInner.length; i++){
							var oneRule = arrInner[i];
							if(!oneRule.trim())
								continue;

							var errorKey = attrs.ngModel + extValidConf.sp + oneRule;
							ctrl.$setValidity(errorKey, r.flag ? true : oneRule != r.rule);
						}
					}

					if(!r.flag){
						extValidTips.on(el[0], r.rule, r.msg);
					}else{
						extValidTips.off(el[0]);
					}
					return r.flag;
				};

				var init = function(){
					var rules = getRules();
					console.log('Init valid : ' + attrs.ngModel);
					console.log(rules);

					if(!rules)
						return;

					// clear ctrl.$parsers, use extValidTips.on/off instead $watch form's $error, as ng-show effect layout
					// eg. email/url directives not useful
					if(ctrl.$parsers && ctrl.$parsers.length > 0){
						ctrl.$parsers.splice(0, ctrl.$parsers.length);
					}

					ctrl.$parsers.unshift(function(value){
						return validFn(value) ? value : undefined;
					});
				};

				// validation relative to other model
				// if rules is dynamical, make sure that rules first set has target model declaration
				// because bellow block only run once
				var rules = getRules();
				if(rules){
					var arr = rules.split(' ');
					var watchedLl = [];

					var i = 0;
					for(; i < arr.length; i++){
						if(!arr[i].contains(':'))
							continue;

						var ruleArr = arr[i].split(':');
						if(!['num', 'watch'].contains(ruleArr[0]))
							continue;
						
						// eg. num:range:targetModelName/watch:targetModelName1,targetModelName2
						var modelName = ruleArr['watch' === ruleArr[0] ? 1 : 2];
						var modelArr = modelName.split(',');
						var j = 0;
						for(; j < modelArr.length; j++){
							var targetModelName = modelArr[j];
							// already watched
							if(watchedLl.contains(targetModelName))
								continue;

							console.log('Add watch for valid check : ' + targetModelName);
							scope.$watch(targetModelName, function(){
								/*
								if you donot want to valid if it's not dirty, add function bellow:
								extValidChecker.filterWatchValid = function(ctrl){
									return ctrl.$dirty;
								};
								*/
								if((extValidChecker.filterWatchValid && extValidChecker.filterWatchValid(ctrl)) || 
									!extValidChecker.filterWatchValid){
									// valid again
									ctrl.$setViewValue(ctrl.$viewValue);
								}
							});
							watchedLl.push(targetModelName);
							extValidRefered[attrs.ngModel] = targetModelName + '|' + ruleArr[0];
						}// \for inner
					}// \for outer
				}

				// Watch this model change and check if last bind failed (ctrl.$invalid === true)
				// eg.
				/*
				<select ng-model="optionVal" ext-valid="r"
					ng-options="one.code as one.name for one in optionList">
					<option value="">--to be choosed--</option>
				</select>
				var MyCtrl = function($scope){
					$scope.optionList = [];
					
					$scope.changeOptionListAndVal = function(){
						$scope.optionList = [{code: 'A', name: 'A'}];
						$scope.optionVal = 'A';

						// tips div should be removed
					};
				};
				*/
				// donot use $formmaters as always show raw value, even $modelValue valid failed
				scope.$watch(attrs.ngModel, function(){
					if(ctrl.$modelValue !== undefined && 
							(ctrl.$invalid || el.hasClass(extValidConf.invalidClass))){
						validFn(ctrl.$modelValue);
					}
				});

				// Watch for changes to the directives options
				// if validation rules change, initialize again
				scope.$watch(getRules, function(newRules, oldRules){
					init();

					// not bind yet include NgModelController initialize value : NaN
					if(ctrl.$modelValue === undefined || 
						ctrl.$modelValue === null || 
						ctrl.$modelValue !== ctrl.$modelValue){
						// bind failed
						// check tips has showed
						var needValid = false;

						// el.hasClass(extValidConf.invalidClass) 
						if(ctrl.$invalid || ctrl.$viewValue !== undefined)
							needValid = true;

						if(needValid){
							lastOldRules = oldRules;
							ctrl.$setViewValue(ctrl.$viewValue);
						}
					}else{
						validFn(ctrl.$modelValue, oldRules);
					}
				}, true);
			}
		};
	});

})(angular);